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:
- Physical hardware dependency — Security keys (YubiKey, etc.) require physical touch events that can't be automated traditionally
- Platform authenticators — macOS Touch ID, Windows Hello require OS-level biometric prompts
- Cryptographic uniqueness — Each credential is unique; you can't replay credentials
- Relying party verification — The WebAuthn server validates the origin, making test environments tricky
- 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.