Specialized Testing Guide

How to Test WebAuthn Passkeys with Playwright: Complete 2026 Guide

A scenario-by-scenario walkthrough of testing WebAuthn passkeys with Playwright. Virtual authenticator setup via Chrome DevTools Protocol, credential creation and assertion ceremonies, resident vs non-resident keys, user verification, conditional UI autofill, and cross-origin passkeys in headless mode.

12B+

Over 12 billion accounts can now use passkeys instead of passwords, according to the FIDO Alliance's 2024 report on passkey adoption across Apple, Google, and Microsoft platforms.

FIDO Alliance

0Browser dialogs in headless
0Scenarios covered
0Ceremony types tested
0%Fewer lines with Assrt

WebAuthn Passkey Registration and Authentication Flow

BrowserYour AppRelying Party ServerAuthenticatorClick 'Register Passkey'POST /webauthn/register/beginPublicKeyCredentialCreationOptionsnavigator.credentials.create()Authenticator creates keypairAttestation responsePOST /webauthn/register/finishRegistration success

1. Why Testing WebAuthn Passkeys Is Harder Than It Looks

WebAuthn passkeys replace passwords with public-key cryptography bound to a hardware or platform authenticator. When a user clicks “Register a Passkey” in your application, the browser calls navigator.credentials.create(), which triggers an authenticator prompt that is entirely outside the DOM. No amount of Playwright locators can interact with the native browser dialog that asks the user to scan a fingerprint, enter a PIN, or tap a security key. In headless mode, that dialog never appears at all, which means the ceremony simply hangs forever unless you provide an alternative authenticator.

The solution is Chrome DevTools Protocol (CDP). Chromium exposes a WebAuthn domain that lets you create virtual authenticators, configure their capabilities (resident keys, user verification, transport type), and programmatically manage credentials. Playwright provides access to CDP sessions through page.context().newCDPSession(page), giving you complete control over the authenticator without any browser dialog.

There are five structural reasons this flow is hard to test reliably. First, the WebAuthn API operates outside the DOM, so traditional locator strategies cannot interact with authentication prompts. Second, the creation ceremony and assertion ceremony have different data flows, and a test that works for registration will not automatically work for login. Third, resident keys (discoverable credentials) and non-resident keys (server-side credentials) behave differently in the authenticator. Fourth, user verification levels (required, preferred, discouraged) change the authenticator response format. Fifth, conditional UI (passkey autofill) requires the mediation: "conditional" parameter and an autocomplete="webauthn" input attribute, introducing yet another code path your tests must cover.

WebAuthn Credential Creation Flow

🌐

User Action

Click Register Passkey

⚙️

Server

Generate challenge + options

🔒

Browser API

navigator.credentials.create()

💳

Authenticator

Create keypair, sign attestation

🌐

Browser

Return AuthenticatorAttestationResponse

Server

Verify attestation, store public key

2. Setting Up Virtual Authenticators via CDP

Every WebAuthn test in this guide depends on a virtual authenticator created through the Chrome DevTools Protocol. The virtual authenticator intercepts all navigator.credentials calls and responds as if a real hardware key or platform biometric were present. You configure the authenticator before your first test and remove it in teardown.

webauthn-setup.ts

The fixture above creates a CTAP2 virtual authenticator with internal transport (simulating a platform authenticator like Touch ID or Windows Hello). The key configuration options are:

  • protocol: 'ctap2' selects the CTAP2 protocol, which supports resident keys and user verification. Use 'u2f' only if you are testing legacy security key flows.
  • hasResidentKey: true enables discoverable credentials. Passkeys are always resident keys (the credential is stored on the authenticator itself).
  • isUserVerified: true tells the virtual authenticator to automatically pass user verification (biometric or PIN). Set to false to test verification failure scenarios.
  • automaticPresenceSimulation: true makes the authenticator respond instantly to every ceremony, skipping the “tap your key” prompt.
Installing dependencies

A critical constraint: the WebAuthn CDP domain is Chromium only. Firefox and WebKit do not expose virtual authenticator APIs, so all WebAuthn tests in your suite must run with the chromium project. Configure this in your playwright.config.ts:

playwright.config.ts

CDP Virtual Authenticator Lifecycle

🌐

Test Starts

beforeEach hook

⚙️

Enable WebAuthn

WebAuthn.enable via CDP

🔒

Add Authenticator

Configure CTAP2 options

Run Test

Ceremonies auto-respond

Cleanup

Remove authenticator + disable

1

Passkey Registration (Creation Ceremony)

Moderate

3. Scenario: Passkey Registration (Creation Ceremony)

The creation ceremony is the WebAuthn flow where a user registers a new passkey. Your application sends PublicKeyCredentialCreationOptions to the browser, the authenticator generates a keypair, and the browser returns an AuthenticatorAttestationResponse containing the public key and attestation data. With the virtual authenticator in place, this entire flow completes without any user interaction.

Goal

Verify that a logged-in user can register a passkey and that the credential is stored on the server. After registration, the user should see their passkey listed in account settings.

Playwright Implementation

tests/webauthn-register.spec.ts

What to Assert Beyond the UI

The UI confirmation message is necessary but not sufficient. Use the CDP WebAuthn.getCredentials command to verify the authenticator actually stored a credential. Check that isResidentCredential is true for passkeys, and verify the rpId matches your application domain. You can also intercept the network request to your registration endpoint and validate the response payload.

2

Passkey Authentication (Assertion Ceremony)

Moderate

4. Scenario: Passkey Authentication (Assertion Ceremony)

The assertion ceremony is how a user logs in with an existing passkey. The server sends PublicKeyCredentialRequestOptions to the browser, the authenticator signs a challenge with the private key, and the browser returns an AuthenticatorAssertionResponse. This test requires a credential to already exist on the virtual authenticator.

Goal

Verify that a user who previously registered a passkey can log in with it. After successful authentication, the user should land on the dashboard.

Playwright Implementation

tests/webauthn-login.spec.ts

Passkey Login: Playwright vs Assrt

import { test, expect } from './webauthn-setup';

test('passkey login', async ({ page, cdp, authenticatorId }) => {
  await cdp.send('WebAuthn.addCredential', {
    authenticatorId,
    credential: {
      credentialId: btoa('test-cred'),
      isResidentCredential: true,
      rpId: 'localhost',
      privateKey: await generateBase64PrivateKey(),
      userHandle: btoa('user-123'),
      signCount: 0,
    },
  });
  await page.goto('/login');
  await page.getByRole('button', { name: 'Sign in with Passkey' }).click();
  await page.waitForURL('/dashboard');
  await expect(page.getByText('Welcome back')).toBeVisible();
});
44% fewer lines

Try Assrt for free

Open-source AI testing framework. No signup required.

Get Started
3

Conditional UI (Autofill) Passkey Login

Complex

5. Scenario: Conditional UI (Autofill) Passkey Login

Conditional UI is the WebAuthn feature that surfaces passkeys in the browser's autofill dropdown. Instead of a dedicated “Sign in with Passkey” button, the user focuses a username or email input field that has autocomplete="username webauthn", and the browser shows available passkeys alongside saved passwords. This is the experience Apple, Google, and Microsoft promote as the default passkey login flow.

Testing conditional UI is tricky because the browser calls navigator.credentials.get() with mediation: "conditional" as soon as the page loads, creating a pending promise that resolves only when the user selects a credential from autofill. In a virtual authenticator scenario, the authenticator automatically responds when a matching credential exists, but the timing depends on when the browser evaluates the conditional get call relative to your test actions.

Playwright Implementation

tests/webauthn-conditional-ui.spec.ts
4

User Verification and Resident Keys

Complex

6. Scenario: User Verification and Resident Keys

User verification (UV) is the WebAuthn mechanism that confirms the person using the authenticator is the legitimate owner. In physical authenticators, this is a fingerprint scan or PIN entry. The relying party can request UV as required, preferred, or discouraged. When UV is required and the authenticator cannot perform it, the ceremony must fail.

Your virtual authenticator's isUserVerified flag controls whether UV succeeds or fails. By toggling this flag between tests, you can verify your application correctly handles both outcomes.

Playwright Implementation

tests/webauthn-uv.spec.ts

UV Failure: Playwright vs Assrt

const test = base.extend({
  authenticatorId: async ({ cdp }, use) => {
    const { authenticatorId } = await cdp.send(
      'WebAuthn.addVirtualAuthenticator',
      {
        options: {
          protocol: 'ctap2',
          transport: 'internal',
          hasResidentKey: true,
          hasUserVerification: true,
          isUserVerified: false,
          automaticPresenceSimulation: true,
        },
      }
    );
    await use(authenticatorId);
  },
});

test('UV failure', async ({ page }) => {
  await page.goto('/settings/security');
  await page.getByRole('button', { name: 'Add a Passkey' }).click();
  await expect(page.getByText(/verification failed/i)).toBeVisible();
});
64% fewer lines
5

Cross-Origin Passkey Authentication

Complex

7. Scenario: Cross-Origin Passkey Authentication

Cross-origin passkeys allow a credential registered on auth.example.com to be used on app.example.com by setting the rpId to the parent domain example.com. This is common in organizations where authentication lives on a separate subdomain. The rpId must be a registrable domain suffix of the current origin per the WebAuthn specification.

Testing this requires your virtual authenticator credential to match the rpId that the server requests. If your credential was created with rpId: "localhost" but the assertion asks for rpId: "example.com", the authenticator will not find the credential. You must align these values across your test data, server configuration, and virtual authenticator setup.

Playwright Implementation

tests/webauthn-cross-origin.spec.ts
6

Error Handling and Edge Cases

Moderate

8. Scenario: Error Handling and Edge Cases

Your application must gracefully handle WebAuthn failures. The browser throws a DOMException when a ceremony fails, with names like NotAllowedError (user cancelled or timeout), InvalidStateError (credential already registered), and SecurityError (rpId mismatch). Your tests should verify that your UI surfaces meaningful error messages for each.

Playwright Implementation

tests/webauthn-errors.spec.ts
WebAuthn test suite run

9. Common Pitfalls That Break WebAuthn Test Suites

WebAuthn testing with virtual authenticators is powerful but introduces failure modes that do not exist in other types of E2E tests. The following pitfalls are sourced from real Chromium bug reports, Playwright GitHub issues, and Stack Overflow threads.

Pitfalls to Avoid

  • Forgetting to call WebAuthn.enable before adding a virtual authenticator. The CDP command silently fails without it.
  • Using the wrong rpId in seeded credentials. The authenticator matches credentials by rpId, and a mismatch means zero credentials returned.
  • Running WebAuthn tests on Firefox or WebKit. The WebAuthn CDP domain is Chromium only; tests will hang indefinitely on other browsers.
  • Not cleaning up virtual authenticators between tests. Leftover authenticators from a previous test can cause unpredictable credential selection.
  • Setting isUserVerified to true but testing a 'required' UV failure path. Toggle the flag per scenario, not globally.
  • Assuming automaticPresenceSimulation works retroactively. You must set it when creating the authenticator; it cannot be changed later.
  • Using signCount: 0 for assertion tests but not incrementing it. Some relying parties reject assertions where the sign count does not increase.
  • Forgetting that conditional UI (mediation: 'conditional') auto-resolves immediately with a virtual authenticator. Add explicit waits for the page state after resolution.

One particularly subtle issue involves the privateKey format when seeding credentials via WebAuthn.addCredential. The CDP expects a Base64-encoded PKCS#8 key. If you provide a raw key or use the wrong encoding, the credential will be created but assertion ceremonies will produce invalid signatures, causing server-side verification to fail. The test passes from the browser's perspective (the authenticator responds), but your backend rejects the assertion. Always generate keys using crypto.subtle.generateKey with the P-256 curve and export in pkcs8 format.

Pre-flight Checklist for WebAuthn Tests

  • Playwright project is set to chromium
  • WebAuthn.enable is called before addVirtualAuthenticator
  • authenticatorId is cleaned up in afterEach or fixture teardown
  • rpId in seeded credentials matches the server configuration
  • Private keys are PKCS#8 P-256, Base64 encoded
  • Tests do not depend on browser-specific autofill behavior

10. Writing These Scenarios in Plain English with Assrt

The Playwright code above is thorough, but it requires deep knowledge of the Chrome DevTools Protocol, CTAP2 options, credential data structures, and PKCS#8 key encoding. Assrt lets you describe WebAuthn scenarios in plain English and handles the virtual authenticator setup, credential seeding, and CDP plumbing automatically.

Full Registration Flow: Playwright vs Assrt

import { test as base, type CDPSession } from '@playwright/test';

const test = base.extend<{ cdp: CDPSession; authenticatorId: string }>({
  cdp: async ({ page }, use) => {
    const client = await page.context().newCDPSession(page);
    await client.send('WebAuthn.enable');
    await use(client);
    await client.send('WebAuthn.disable');
  },
  authenticatorId: async ({ cdp }, use) => {
    const { authenticatorId } = await cdp.send(
      'WebAuthn.addVirtualAuthenticator',
      { options: { protocol: 'ctap2', transport: 'internal',
        hasResidentKey: true, hasUserVerification: true,
        isUserVerified: true, automaticPresenceSimulation: true } }
    );
    await use(authenticatorId);
    await cdp.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
  },
});

test('register passkey', async ({ page, cdp, authenticatorId }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('testuser@example.com');
  await page.getByLabel('Password').fill('SecureP@ss123');
  await page.getByRole('button', { name: 'Sign In' }).click();
  await page.waitForURL('/dashboard');
  await page.goto('/settings/security');
  await page.getByRole('button', { name: 'Add a Passkey' }).click();
  await expect(page.getByText('Passkey registered successfully')).toBeVisible();
  const { credentials } = await cdp.send('WebAuthn.getCredentials', { authenticatorId });
  expect(credentials).toHaveLength(1);
});
69% fewer lines

Here is a complete Assrt test file covering the core scenarios from this guide:

tests/webauthn.assrt

Assrt compiles each scenario into the same Playwright TypeScript shown in the preceding sections. The generated code includes the CDP session setup, virtual authenticator provisioning, credential seeding, and proper teardown. When the WebAuthn specification evolves or Chromium updates its CDP domain, Assrt regenerates the underlying code while your scenario files stay unchanged.

Start with the registration happy path. Once it passes in CI, add the assertion ceremony, then conditional UI, then the UV failure path, then the cross-origin scenario. In a single afternoon you can build complete WebAuthn passkey coverage that most applications never achieve by hand, because most teams never get past the “how do I even trigger a ceremony without a real authenticator?” question.

Related Guides

Ready to automate your testing?

Assrt discovers test scenarios, writes Playwright tests from plain English, and self-heals when your UI changes.

$npm install @assrt/sdk