WebAuthn API Testing with Playwright: A Complete Guide

WebAuthn API Testing with Playwright: A Complete Guide

WebAuthn (Web Authentication API) enables passwordless authentication using hardware security keys, platform authenticators, and biometrics. Testing WebAuthn flows presents unique challenges: the API requires browser interaction with physical hardware, and the ceremonies (registration and authentication) involve cryptographic operations that normally can't be scripted. Playwright solves this with virtual authenticator support — letting you test the full WebAuthn flow without physical devices.

Why WebAuthn Testing Is Difficult

Before looking at solutions, understand the challenges:

  1. Physical hardware dependency — Security keys (YubiKey, etc.) require physical touch events that can't be automated traditionally
  2. Platform authenticators — macOS Touch ID, Windows Hello require OS-level biometric prompts
  3. Cryptographic uniqueness — Each credential is unique; you can't replay credentials
  4. Relying party verification — The WebAuthn server validates the origin, making test environments tricky
  5. Browser sandbox — The WebAuthn dialog is browser-controlled, not page-level, so standard DOM manipulation doesn't work

Playwright's virtual authenticator API addresses all of these.

Playwright Virtual Authenticator Setup

Playwright's CDP (Chrome DevTools Protocol) exposes a virtual authenticator that simulates FIDO2-compliant devices:

const { chromium } = require('@playwright/test');

async function createVirtualAuthenticator(page) {
  const client = await page.context().newCDPSession(page);
  
  await client.send('WebAuthn.enable', { enableUI: false });
  
  const { authenticatorId } = await client.send('WebAuthn.addVirtualAuthenticator', {
    options: {
      protocol: 'ctap2',
      transport: 'usb',
      hasResidentKey: true,
      hasUserVerification: true,
      isUserVerified: true,
    },
  });
  
  return { client, authenticatorId };
}

The isUserVerified: true setting automatically approves user presence/verification checks, bypassing the physical interaction requirement.

Testing WebAuthn Registration (Credential Creation)

The registration ceremony creates a new credential bound to the user's device. Test the full flow:

import { test, expect } from '@playwright/test';

test('WebAuthn registration flow', async ({ page }) => {
  // Set up virtual authenticator
  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,
    },
  });

  // Navigate to registration page
  await page.goto('/register');
  await page.fill('[name="username"]', 'testuser@example.com');
  await page.click('[data-testid="register-passkey-btn"]');

  // The browser's WebAuthn dialog is automatically handled by the virtual authenticator
  // Wait for registration to complete
  await expect(page.locator('[data-testid="success-message"]')).toBeVisible({
    timeout: 10000,
  });

  // Verify the credential was saved
  await expect(page.locator('[data-testid="registered-passkeys"]')).toContainText('Passkey registered');
});

Testing WebAuthn Authentication (Assertion)

Once a credential is registered, test the authentication ceremony:

test('WebAuthn authentication flow', async ({ page }) => {
  const client = await page.context().newCDPSession(page);
  await client.send('WebAuthn.enable', { enableUI: false });
  
  const { authenticatorId } = await client.send('WebAuthn.addVirtualAuthenticator', {
    options: {
      protocol: 'ctap2',
      transport: 'internal',
      hasResidentKey: true,
      hasUserVerification: true,
      isUserVerified: true,
    },
  });

  // Step 1: Register a credential (setup)
  await page.goto('/register');
  await page.fill('[name="username"]', 'testuser@example.com');
  await page.click('[data-testid="register-passkey-btn"]');
  await expect(page.locator('[data-testid="success-message"]')).toBeVisible();

  // Step 2: Log out and authenticate
  await page.click('[data-testid="logout-btn"]');
  await page.goto('/login');
  await page.click('[data-testid="use-passkey-btn"]');

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

  // Verify authenticated state
  await expect(page.locator('[data-testid="user-email"]')).toContainText('testuser@example.com');
});

Testing Error Scenarios

WebAuthn has multiple failure modes that need testing.

No Credentials Available

test('shows helpful error when no passkey registered', async ({ page }) => {
  const client = await page.context().newCDPSession(page);
  await client.send('WebAuthn.enable', { enableUI: false });
  
  // Add virtual authenticator with NO credentials
  await client.send('WebAuthn.addVirtualAuthenticator', {
    options: {
      protocol: 'ctap2',
      transport: 'internal',
      hasResidentKey: true,
      hasUserVerification: true,
      isUserVerified: true,
    },
  });

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

  // Should show error, not crash
  await expect(page.locator('[data-testid="error-message"]')).toContainText(
    'No passkey found'
  );
});

User Verification Failure

test('handles user verification failure gracefully', async ({ page }) => {
  const client = await page.context().newCDPSession(page);
  await client.send('WebAuthn.enable', { enableUI: false });
  
  // Authenticator with UV=false (biometric fails)
  await client.send('WebAuthn.addVirtualAuthenticator', {
    options: {
      protocol: 'ctap2',
      transport: 'internal',
      hasResidentKey: true,
      hasUserVerification: false,
      isUserVerified: false,
    },
  });

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

  // Should show appropriate error
  await expect(page.locator('[data-testid="error-message"]')).toBeVisible();
});

Testing Server-Side WebAuthn Validation

Browser-level tests verify the flow. Server-side tests verify the cryptographic validation.

Unit Testing RP (Relying Party) Logic

// Using the SimpleWebAuthn library server-side
const { verifyRegistrationResponse } = require('@simplewebauthn/server');

describe('WebAuthn server verification', () => {
  it('verifies valid registration response', async () => {
    const verification = await verifyRegistrationResponse({
      response: mockRegistrationResponse,
      expectedChallenge: 'test-challenge-base64',
      expectedOrigin: 'https://example.com',
      expectedRPID: 'example.com',
    });

    expect(verification.verified).toBe(true);
    expect(verification.registrationInfo).toBeDefined();
    expect(verification.registrationInfo.credentialID).toBeDefined();
  });

  it('rejects registration from wrong origin', async () => {
    await expect(
      verifyRegistrationResponse({
        response: mockRegistrationResponse,
        expectedChallenge: 'test-challenge-base64',
        expectedOrigin: 'https://malicious-site.com', // wrong origin
        expectedRPID: 'example.com',
      })
    ).rejects.toThrow(/origin/i);
  });
});

CI/CD Integration

Running WebAuthn Tests in CI

Playwright's virtual authenticator requires Chrome/Chromium. Ensure your CI environment uses it:

# .github/workflows/test.yml
name: WebAuthn E2E Tests

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Install Playwright
        run: npx playwright install chromium
      
      - name: Run WebAuthn tests
        run: npx playwright test tests/webauthn/
        env:
          BASE_URL: ${{ secrets.STAGING_URL }}

Note: Virtual authenticators only work with Chromium-based browsers. Firefox and Safari don't support the CDP virtual authenticator protocol.

Separating WebAuthn Tests

Tag WebAuthn tests distinctly since they require Chromium:

// playwright.config.ts
export default defineConfig({
  projects: [
    {
      name: 'chromium-webauthn',
      use: { ...devices['Desktop Chrome'] },
      testMatch: '**/webauthn/**/*.spec.ts',
    },
  ],
});

Monitoring WebAuthn Flows with HelpMeTest

WebAuthn flows in production need monitoring — a misconfigured RP ID or origin mismatch can silently break authentication for all users. HelpMeTest's scheduled tests can:

  • Verify the registration ceremony completes within acceptable time
  • Confirm authentication succeeds for users with existing passkeys
  • Alert immediately if the WebAuthn endpoint returns errors

Set up a 5-minute health check on your authentication flow to catch WebAuthn regressions before users do.

Testing Browser Compatibility Fallbacks

WebAuthn may not be available in all browsers or contexts. Test the fallback:

test('falls back to password login when WebAuthn unavailable', async ({ page }) => {
  // Disable WebAuthn by overriding the API
  await page.addInitScript(() => {
    delete window.PublicKeyCredential;
  });

  await page.goto('/login');

  // Passkey button should be hidden or show "not supported" message
  await expect(page.locator('[data-testid="use-passkey-btn"]')).not.toBeVisible();
  
  // Password form should be available as fallback
  await expect(page.locator('[name="password"]')).toBeVisible();
});

Summary

Testing WebAuthn with Playwright requires: using CDP virtual authenticators to simulate FIDO2 devices, testing both the registration and authentication ceremonies, covering error scenarios including no credentials and UV failure, adding server-side tests for RP validation logic, running Chromium-only for virtual authenticator support in CI, and monitoring WebAuthn flows in production with scheduled E2E tests. Virtual authenticators make previously untestable flows fully automatable — there's no excuse for shipping WebAuthn without test coverage.

Read more