Skip to main content

How to Test Passkeys and WebAuthn in Playwright

Short answer

Passkeys cannot be clicked like passwords—WebAuthn requires platform authenticators or CDP virtual authenticators in Chromium. Playwright exposes context.addInitScript plus Chrome DevTools Protocol WebAuthn commands to register and assert credentials. Test registration, login, fallback to password, and probe Assert on session—not only that the passkey button rendered.

Part of Testing Guides by auth and identity.

Who this is for

Teams shipping passkey / WebAuthn login (FIDO2, platform authenticators, security keys) on web who need Playwright E2E in headless Chromium CI—without physical YubiKeys on every runner.

Typical stacks: Custom WebAuthn via @simplewebauthn/browser, Auth0 passkeys, Firebase passkeys, Clerk Web3/passkeys beta, or enterprise passwordless rollouts alongside MFA.

Why passkey testing matters

Passkey bugs strand users:

  • Registration succeeds, login fails — credential ID mismatch across subdomains
  • No fallback — WebAuthn unavailable; user locked out in older browsers
  • Replay / clone — same credential on two accounts (should be rejected server-side)
  • Conditional UI — autofill path untested vs button path
  • CI blindness — tests skip WebAuthn entirely; prod breaks on first passkey user

E2E must exercise navigator.credentials.create and get paths with virtual authenticators—not mock WebAuthn in unit tests only.

Complexity map

ScenarioEdge caseWhy tests breakApproach
No authenticatorDefault ChromiumNotSupportedErrorCDP WebAuthn.enable + virtual
RegistrationResident key requiredcreate() failshasResidentKey: true in virtual
User verificationrequired vs preferredUV flag mismatchMatch server policy in virtual
RP IDapp.test vs wwwLogin failsAlign PLAYWRIGHT_BASE_URL hostname
Cross-originiframesBlocked WebAuthnTop-level only in E2E
Password fallbackPasskey failsNo alternate pathNegative + fallback spec
2FA + passkeyStacked factorsComplex ceremonySplit scenarios
Safari / FirefoxNo CDP virtualChromium-only CIDocument browser matrix
Credential deleteRe-register same userDuplicate credsProbe credential count
AttestationEnterprise attestationCI rejectsisUserVerified + format flags
Conditional mediationAutofill UIFlakyautocomplete=username webauthn spec
TimeoutUser absentHangSet authenticator timeout
Backup codesLost deviceUntested recoverySeparate recovery guide
Platform vs cross-platformauthenticatorAttachmentWrong attachmentVirtual authenticatorAttachment
Parallel testsShared virtual credsCollisionNew context per test

Playwright WebAuthn (official pattern)

Playwright documents virtual authenticator setup via CDP:

// tests/fixtures/webauthn.ts
import { test as base, chromium } from '@playwright/test';

type WebAuthnFixtures = {
webAuthnContext: import('@playwright/test').BrowserContext;
};

export const test = base.extend<WebAuthnFixtures>({
webAuthnContext: async ({}, use) => {
const browser = await chromium.launch();
const context = await browser.newContext();
const cdp = await context.newCDPSession(await context.newPage());
await cdp.send('WebAuthn.enable');
await cdp.send('WebAuthn.addVirtualAuthenticator', {
options: {
protocol: 'ctap2',
transport: 'internal',
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
},
});
await use(context);
await browser.close();
},
});

See Playwright WebAuthn for the latest API—prefer official helpers when available on your Playwright version.

Registration flow

// @Scenario: auth/passkey-register
import { test, expect } from '../fixtures/webauthn';

test('user registers passkey and session is valid', async ({ webAuthnContext, request, runId }) => {
const page = await webAuthnContext.newPage();
await request.post('/api/test/seed-user', { data: { runId, email: `pk+${runId}@test.local` } });

await page.goto('/login');
await page.getByLabel('Email').fill(`pk+${runId}@test.local`);
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Create passkey' }).click();

// WebAuthn prompt handled by virtual authenticator automatically
await expect(page.getByText(/passkey created/i)).toBeVisible({ timeout: 15_000 });

await expect.poll(async () => {
const res = await request.get(`/api/test/probe-webauthn-credentials?runId=${runId}`);
return (await res.json()).count;
}).toBe(1);

await expect.poll(async () => {
const res = await page.request.get('/api/test/probe-session');
return (await res.json()).authenticated;
}).toBe(true);
});

Login with existing credential

Seed credential via API in Arrange when registration is not the SUT:

await request.post('/api/test/seed-webauthn-credential', {
data: { runId, userId, credentialId: `cred-${runId}` },
});

await page.goto('/login');
await page.getByRole('button', { name: 'Sign in with passkey' }).click();

await expect.poll(() => probeSession(page)).toBe(true);

Server-side seed must create valid credential records matching your WebAuthn verification library (SimpleWebAuthn, etc.).

Fallback when WebAuthn unavailable

test('password fallback when passkey not supported', async ({ browser, runId }) => {
// Context WITHOUT virtual authenticator
const context = await browser.newContext();
const page = await context.newPage();
await page.addInitScript(() => {
// Optional: force isUserVerifyingPlatformAuthenticatorAvailable false
});

await page.goto('/login');
await page.getByRole('button', { name: 'Use password instead' }).click();
await page.getByLabel('Password').fill(`pw-${runId}`);
await page.getByRole('button', { name: 'Sign in' }).click();

await expect.poll(() => probeSession(page)).toBe(true);
await context.close();
});

CDP credential inspection (debug)

const client = await page.context().newCDPSession(page);
const { credentials } = await client.send('WebAuthn.getCredentials', {
authenticatorId,
});
expect(credentials.length).toBeGreaterThan(0);

Use sparingly—probe session remains authoritative for app state.

Anti-patterns

Anti-patternWhy it failsBetter approach
Skip WebAuthn in CIProd-only failuresVirtual authenticator
Mock navigator.credentialsServer verification untestedCDP virtual + real API
Physical key on one laptopNot parallelizableVirtual authenticator
Assert button visible onlyCeremony never runsComplete register/login
Wrong RP ID in test envWorks locally onlyMatch hostname to prod pattern
Firefox-only job without docFalse confidenceChromium CI + manual matrix

External references

Example scenario

Situation: User registers passkey on staging subdomain; production RP ID differs—login ceremony fails silently.

Expected outcome: Registration and login succeed when RP ID matches deployed hostname; session probe authenticated.

Why UI-only automation breaks: Create passkey button shows success toast while credential row missing in DB.

  1. Arrange: Virtual authenticator enabled; seed user with runId; RP ID aligned to PLAYWRIGHT_BASE_URL host.
  2. Act: Click Create passkey; complete WebAuthn ceremony via virtual authenticator.
  3. Assert: Probe webauthn_credentials count=1; probe session authenticated.

TestChimp workflow: // @Scenario: auth/passkey-register; document Firefox/Safari manual matrix outside Chromium CI.

Same Arrange/Act/Assert pattern as expired-coupon checkout.

Frequently asked questions

Does Playwright support passkeys in headless CI?

Yes in Chromium via CDP WebAuthn virtual authenticators—see Playwright WebAuthn docs. Firefox and Safari need separate manual or vendor-specific strategies.

How do I test passkey registration vs login?

Registration spec runs create() ceremony with empty virtual authenticator. Login spec seeds credential via API or reuses prior registration—assert probe session both times.

What RP ID should tests use?

Match your app hostname (e.g. app.test in CI). RP ID mismatch is the most common passkey works-locally-only bug.

Can I test security keys (cross-platform) vs platform passkeys?

Virtual authenticator options include transport internal vs usb-nfc-bluetooth. Set authenticatorAttachment to match product policy.

How do I test password fallback?

Use browser context without virtual authenticator or simulate unsupported WebAuthn; assert fallback path reaches probe-authenticated session.

Should I mock navigator.credentials?

Avoid for E2E—it bypasses server verification. CDP virtual authenticator exercises the real client ceremony.

Passkeys plus MFA—how to structure tests?

Split scenarios: passkey-only login, MFA-only, and combined if product requires. Probe session after each factor completes.

TestChimp with passkey flows?

/testchimp init adds session and credential probes; /testchimp test maintains WebAuthn specs when auth UI changes—virtual authenticator setup stays in fixtures.

Apply these patterns in your repo

Run `/testchimp init` to connect TestChimp to your repo, then `/testchimp test` on PRs to turn these patterns into maintained SmartTests. Use `/testchimp evolve` when you want to expand coverage as your app grows.

Start free on TestChimp · Book a demo