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.
“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
WebAuthn Passkey Registration and Authentication Flow
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.
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: trueenables discoverable credentials. Passkeys are always resident keys (the credential is stored on the authenticator itself).isUserVerified: truetells the virtual authenticator to automatically pass user verification (biometric or PIN). Set tofalseto test verification failure scenarios.automaticPresenceSimulation: truemakes the authenticator respond instantly to every ceremony, skipping the “tap your key” prompt.
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:
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
Passkey Registration (Creation Ceremony)
Moderate3. 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
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.
Passkey Authentication (Assertion Ceremony)
Moderate4. 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
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();
});Conditional UI (Autofill) Passkey Login
Complex5. 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
User Verification and Resident Keys
Complex6. 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
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();
});Cross-Origin Passkey Authentication
Complex7. 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
Error Handling and Edge Cases
Moderate8. 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
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);
});Here is a complete Assrt test file covering the core scenarios from this guide:
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
How to Test Auth0 Universal Login
A practical, scenario-by-scenario guide to testing Auth0 Universal Login with Playwright....
How to Test Azure AD Login
A practical, scenario-by-scenario guide to testing Azure AD (Entra ID) login with...
How to Test Clerk Sign-In
A practical, scenario-by-scenario guide to testing Clerk authentication with Playwright....
Ready to automate your testing?
Assrt discovers test scenarios, writes Playwright tests from plain English, and self-heals when your UI changes.